Handle response content length mismatches (#175)#1155
Conversation
9c4f978 to
83a49d0
Compare
| var responseHeaders = FrameResponseHeaders; | ||
| if (responseHeaders != null && responseHeaders.HasContentLength) | ||
| { | ||
| _responseContentLength = int.Parse(responseHeaders.HeaderContentLength.ToString()); |
There was a problem hiding this comment.
I'll leave it as Parse since that will throw if the app does something like httpContext.Response.Headers["Content-Length"] = "foo";
There was a problem hiding this comment.
In that case you should throw from the header setter ideally.
I think we should make sure we have the same logic we use in HttpAbstractions. We should probably look into sharing this with HttpAbsractions somehow.
It's also a good opportunity to make sure we use the same logic for parsing the request header content-length. Right now, it seems like we're probably more lax than we should be.
There was a problem hiding this comment.
Oh. And I see you changed this to a long 👍 Were there not any tests failing when this was int.Parse?
There was a problem hiding this comment.
No test failures :( It was bound to blow up somewhere.
There was a problem hiding this comment.
Wow. I thought our LargeDownload test wrote over 2GB like the LargeUpload test. It looks like the LargeDownload test only has a 1MB body.
We should change the LargeDownload test to be like the LargeUpload test where we check every byte value in a few megabyte scenario, but still ensure that sending over 2GB works.
|
|
||
| protected void TryInitializeResponseContentLength() | ||
| { | ||
| if (!_responseContentLength.HasValue) |
There was a problem hiding this comment.
If there's no content-length header this will re-run for every write. You need a flag to only run it once, or is HasContentLength really that fast?
|
|
||
| if (_responseContentLength.HasValue && _responseBytesWritten < _responseContentLength.Value) | ||
| { | ||
| ThrowContentLengthMismatchException(); |
There was a problem hiding this comment.
This should be a different message "Too few bytes written".
There was a problem hiding this comment.
Why even throw at this point? Why not just directly invoke ReportApplicationError?
There was a problem hiding this comment.
Why have two different messages when we can have a single one that reports the error in the same way?
There was a problem hiding this comment.
because they are different conditions, and the message should be clear which is which.
| protected void ThrowContentLengthMismatchException() | ||
| { | ||
| _keepAlive = false; | ||
| throw new InvalidOperationException($"Response content length mismatch: wrote {_responseBytesWritten} bytes to {_responseContentLength.Value}-byte response."); |
There was a problem hiding this comment.
Yes, Stream allows InvalidOperationException.
https://msdn.microsoft.com/en-us/library/hh137799(v=vs.110).aspx
| } | ||
|
|
||
| _responseBytesWritten += data.Count; | ||
| VerifyWrite(); |
There was a problem hiding this comment.
this calling pattern is misleading, _responseBytesWritten gets incremented regardless of if you've written the bytes out. Invert it:
VerifyWrite(data.Count);
_responseBytesWritten += data.Count;
and/or move _responseBytesWritten += data.Count; inside of VerifyWrite and make it VerifyAndUpdateWrite
|
@Tratcher I've addressed your comments. |
| var responseHeaders = FrameResponseHeaders; | ||
| if (responseHeaders != null && responseHeaders.HasContentLength) | ||
| { | ||
| _responseContentLength = long.Parse(responseHeaders.HeaderContentLength.ToString(), NumberStyles.None); |
There was a problem hiding this comment.
This will result in a FormatException being thrown from Write. Use TryParse and ignore values you can't parse.
When determining if you should chunk, do you check for the presence of a content-length or if you understand it?
There was a problem hiding this comment.
We check for its presence but we don't try to parse it.
I disagree with you on this point. We should throw if the Content-Length header is set to an invalid value. The whole point of this change is to track errors related to response Content-Length, this would be one of them. Without a valid value, we can't track it.
There was a problem hiding this comment.
Even if you want to throw, you can't throw a FormatException from Stream.Write. It needs to be wrapped in an InvalidOperationException
|
Updated. |
|
Added validation to header setter. Not sure I covered all bases though. I'm thinking of adding a |
| } | ||
| set | ||
| { | ||
| ValidateLongValue(value); |
There was a problem hiding this comment.
can you cache the parsed value internally rather than re-parse it later?
There was a problem hiding this comment.
Yeah, it's what I was referring to in my comment above.
|
|
||
| public static void ValidateLongValue(StringValues value) | ||
| { | ||
| long.Parse(value, NumberStyles.AllowLeadingWhite | NumberStyles.AllowTrailingWhite, CultureInfo.InvariantCulture); |
There was a problem hiding this comment.
Might make things a little easier to debug if this was wrapped in an InvalidOperationException stating the Content-Length header must be a positive decimal number or something like this.
|
Updated. Caching Content-Length and wrapping |
1aa76e4 to
a837285
Compare
|
|
||
| private long _bits = 0; | ||
| private HeaderReferences _headers; | ||
| private long? _contentLength; |
There was a problem hiding this comment.
Does this get cleared if I call Headers.Remove("Content-Length")?
There was a problem hiding this comment.
Good catch. We should add a test case for this.
a837285 to
a7e7044
Compare
|
I'm applying the |
| "GET / HTTP/1.1", | ||
| "", | ||
| ""); | ||
| await connection.ReceiveEnd( |
There was a problem hiding this comment.
Would this result in a RST? or just a FIN?
|
|
||
| using (var server = new TestServer(httpContext => | ||
| { | ||
| httpContext.Response.ContentLength = 0; |
There was a problem hiding this comment.
What if you added a version of this test that called httpContext.Respose.Body.Flush() here? Same result I hope.
There was a problem hiding this comment.
Same result. I've modified this test to test with and without flushing.
| {{ | ||
| if ({header.TestBit()}) | ||
| {{ | ||
| {{{If(header.Identifier == "ContentLength", () => $@" |
There was a problem hiding this comment.
Missed loop.ClassName == "FrameResponseHeaders" && here.
| {{ | ||
| _bits = 0; | ||
| _headers = default(HeaderReferences); | ||
| _contentLength = null; |
| protected Task TryProduceInvalidRequestResponse() | ||
| { | ||
| if (_requestRejected) | ||
| if (_requestRejected || _applicationException != null) |
There was a problem hiding this comment.
Obviously the application could swallow the exception, so we shouldn't be relying on it. And this change has little to do with invalid requests, right? I guess we're now parsing the request content-length more strictly...
There was a problem hiding this comment.
If the response hasn't already started by the time we detect a mismatch in the content length, we can send a 500 response. _applicationException is set when there's a mismatch, so we need this check to run the error response logic.
There was a problem hiding this comment.
That should be handled by the normal call to ProduceEnd().
There was a problem hiding this comment.
Indeed. My very first change (that I didn't even commit) was calling Abort() on a content length mismatch. This would prevent the call to ProduceEnd() in FrameOfT. But since I moved away from that decision, ProduceEnd() handles it as expected.
|
I don't think we need a new event id for this. Treating it as a normal application error is fine with me. |
|
Updated. |
8e1eb31 to
6412753
Compare
|
|
| _headers._raw{header.Identifier} = null;")} | ||
| }} | ||
| }}")} | ||
| {(loop.ClassName == "FrameResponseHeaders" ? "public long? HeaderContentLengthValue => _contentLength;" : "")} |
There was a problem hiding this comment.
nit: I would probably put this in FrameResponseHeaders.cs
| return $@" | ||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Globalization; |
There was a problem hiding this comment.
nit: I don't think this is being used.
| {{{Each(byLength, header => $@" | ||
| if (""{header.Name}"".Equals(key, StringComparison.OrdinalIgnoreCase)) | ||
| {{ | ||
| {{{If(loop.ClassName == "FrameResponseHeaders" && header.Identifier == "ContentLength", () => $@" |
| }} | ||
| }}{ | ||
| If(loop.ClassName == "FrameResponseHeaders" && header.Identifier == "ContentLength", () => $@" | ||
| _contentLength = ParseContentLength(value);")} |
| }} | ||
| set | ||
| {{ | ||
| {{{If(loop.ClassName == "FrameResponseHeaders" && header.Identifier == "ContentLength", () => $@" |
| {{ | ||
| if ({header.TestBit()}) | ||
| {{ | ||
| {{{If(loop.ClassName == "FrameResponseHeaders" && header.Identifier == "ContentLength", () => $@" |
|
@halter73 nits addressed :) |
2e6ffce to
f8813a6
Compare
#175
InvalidOperationExceptionmight not be the right exception type for this@halter73 @Tratcher @mikeharder @davidfowl